﻿// --------------------------------------------------------------------------------------------------------------------
// <copyright file="IsolatedStorageCacheProvider.cs" company="XamlNinja">
//   2011 Richard Griffin and Ollie Riches
// </copyright>
// <summary>
//   An Isolated Storage Cache Provider - uses the SilverlightSerializer to serialize\deserialize the cache to a byte array
// </summary>
// --------------------------------------------------------------------------------------------------------------------

namespace WP7Contrib.Caching
{
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.IO.IsolatedStorage;
    using System.Linq;
    using System.Reactive.Linq;
    using System.Reflection;
    using Logging;
    using Serialization;
    using Common;

    /// <summary>
    /// The isolated storage cache provider.
    /// </summary>
    public sealed class IsolatedStorageCacheProvider : ICacheProvider, IDisposable
    {
        /// <summary>
        /// The cache filename.
        /// </summary>
        private const string CacheFilename = "cache.dat";

        /// <summary>
        /// The write interval.
        /// </summary>
        private const int WriteInterval = 15000;

        /// <summary>
        /// The name of application using the cache.
        /// </summary>
        private readonly string applicationName;

        /// <summary>
        /// The logger used to capture diagnostics information.
        /// </summary>
        private readonly ILog log;

        /// <summary>
        /// The sync object to make accessing the dictionary is thread safe.
        /// </summary>
        private readonly object sync = new object();

        /// <summary>
        /// The SilverlightSerializer assemblies which require registration.
        /// </summary>
        private readonly List<Assembly> serialisationAssemblies;

        /// <summary>
        /// The cache path.
        /// </summary>
        private string cachePath;

        /// <summary>
        /// The isolated storage.
        /// </summary>
        private IsolatedStorageFile isolatedStorage;

        /// <summary>
        /// The dictionary containing the key value pairs - the key must implement a hashing algorithm that generates unique hash values.
        /// </summary>
        private IDictionary<object, ValueExpiry> dictionary = new Dictionary<object, ValueExpiry>();

        /// <summary>
        /// The periodic observer which executes persisting the cache to isolated storage.
        /// </summary>
        private IDisposable persistObserver;

        /// <summary>
        /// Flag indicating the cache needs persisting.
        /// </summary>
        private volatile bool persistRequired;

        /// <summary>
        /// Flag indicating whether the cache has been revived from file.
        /// </summary>
        private bool loaded;

        /// <summary>
        /// Initializes a new instance of the <see cref="IsolatedStorageCacheProvider"/> class.
        /// </summary>
        /// <param name="applicationName">
        /// The application name.
        /// </param>
        /// <param name="assemblies">
        /// The SilverlightSerializer assemblies we need to register for serialisation\deserialisation.
        /// </param>
        public IsolatedStorageCacheProvider(string applicationName, IEnumerable<Assembly> assemblies) : this(applicationName, assemblies, new DebugLog())
        {
        }

        /// <summary>
        /// Initializes a new instance of the <see cref="IsolatedStorageCacheProvider"/> class.
        /// </summary>
        /// <param name="applicationName">
        /// The application name.
        /// </param>
        /// <param name="assemblies">
        /// The SilverlightSerializer assemblies we need to register for serialisation\deserialisation.
        /// </param>
        /// <param name="log">
        /// The logger used to capture diagnostics information.
        /// </param>
        public IsolatedStorageCacheProvider(string applicationName, IEnumerable<Assembly> assemblies, ILog log)
        {
            this.applicationName = applicationName;
            this.log = log;

            this.serialisationAssemblies = new List<Assembly>();
            this.serialisationAssemblies.AddRange(assemblies);
        }

        #region ICacheProvider Members

        /// <summary>
        /// Add a value to the cache with a relative expiry time, e.g 10 minutes.
        /// </summary>
        /// <typeparam name="TKey">
        /// The type of the key.
        /// </typeparam>
        /// <typeparam name="TValue">
        /// The type of the value.
        /// </typeparam>
        /// <param name="key">
        /// The key value.
        /// </param>
        /// <param name="value">
        /// The value, can be null.
        /// </param>
        /// <param name="relativeTime">
        /// The relative date time when the key value pair should expire and be purged from the cache.
        /// </param>
        public void Add<TKey, TValue>(TKey key, TValue value, TimeSpan relativeTime) where TValue : class
        {
            this.AddImpl(key, value, relativeTime);
        }

        /// <summary>
        /// Add a value to the cache with an absolute time, e.g. 01/01/2020.
        /// </summary>
        /// <typeparam name="TKey">
        /// The type of the key.
        /// </typeparam>
        /// <typeparam name="TValue">
        /// The type of the value.
        /// </typeparam>
        /// <param name="key">
        /// The key value.
        /// </param>
        /// <param name="value">
        /// The value, can be null.
        /// </param>
        /// <param name="absoluteTime">
        /// The absolute date time when the cache should expire and be purged the value.
        /// </param>
        public void Add<TKey, TValue>(TKey key, TValue value, DateTime absoluteTime) where TValue : class
        {
            if (absoluteTime < DateTime.Now)
            {
                return;
            }

            var diff = absoluteTime - DateTime.Now;
            this.AddImpl(key, value, diff);
        }

        /// <summary>
        /// Gets a value from the cache.
        /// </summary>
        /// <typeparam name="TKey">
        /// The type of the key.
        /// </typeparam>
        /// <typeparam name="TValue">
        /// The type of the value.
        /// </typeparam>
        /// <param name="key">
        /// The key associated with value.
        /// </param>
        /// <returns>
        /// If the key exists in the cache then the value is returned, if the key does not exist then null is returned.
        /// </returns>
        public TValue Get<TKey, TValue>(TKey key) where TValue : class
        {
            if (Equals(key, null))
            {
                return null;
            }

            if (!this.loaded)
            {
                this.ReviveCache();
            }

            ValueExpiry value;
            if (this.dictionary.TryGetValue(key, out value))
            {
                return (TValue)value.Value;
            }

            return null;
        }

        /// <summary>
        /// Remove a value from the cache.
        /// </summary>
        /// <param name="key">
        /// The key associated with the value.
        /// </param>
        /// <typeparam name="TKey">
        /// The type of the key.
        /// </typeparam>
        public void Remove<TKey>(TKey key)
        {
            if (Equals(key, null))
            {
                return;
            }

            if (!this.loaded)
            {
                this.ReviveCache();
            }

            this.Purge(key);
        }

        /// <summary>
        /// Clears the contents of the cache.
        /// </summary>
        public void Clear()
        {
            if (!this.loaded)
            {
                this.ReviveCache();
            }

            lock (this.sync)
            {
                this.dictionary.Clear();
                this.persistRequired = true;
            }
        }

        /// <summary>
        /// When called attempts to revive the cache from isolated storage, otherwise this will be done first time the cache
        /// is used.
        /// </summary>
        public void PreemptiveInitialise()
        {
            this.log.Write("IsolatedStorageCacheProvider: PreemptiveInitialise..."); 
            this.ReviveCache();
        }

        /// <summary>
        /// Returns true if the value be cached.
        /// </summary>
        /// <typeparam name="T">The type of the value.</typeparam>
        /// <param name="value">The value to checkd for cachability.</param>
        /// <returns>Returns true if the value can be added to the cache.</returns>
        public bool IsCacheable<T>(T value, ref IEnumerable<Type> failingTypes) where T : class
        {
            if (value == null)
            {
                return true;
            }

            var validator = new SilverlightSerializerValidator(this.log);
            var canBeSerialized = validator.CanBeSerialized(value, ref failingTypes);

            this.log.Write("IsolatedStorageCacheProvider: Type '{0}' can be serialized = {1}", value.GetType().Name, canBeSerialized.ToString().ToLower());
            return canBeSerialized;
        }

        /// <summary>
        /// Gets an enumerator for keys of a specific type.
        /// </summary>
        /// <typeparam name="TKey">The type of the key.</typeparam>
        /// <returns>Returns an enumerator for keys of a specific type.</returns>
        public IEnumerable<TKey> Keys<TKey>()
        {
            lock (sync)
            {
                return this.dictionary.Keys.Where(k => k.GetType() == typeof (TKey)).Cast<TKey>().ToList();
            }
        }

        #endregion

        /// <summary>
        /// Dispose of resources - shutsdown the persist observer
        /// </summary>
        public void Dispose()
        {
            if (this.persistObserver != null)
            {
                this.persistObserver.Dispose();
                this.persistObserver = null;
            }

            if (this.isolatedStorage != null)
            {
                this.PersistCache();

                this.isolatedStorage.Dispose();
                this.isolatedStorage = null;
            }
        }

        /// <summary>
        /// The implementation of adding a value to the cache.
        /// </summary>
        /// <typeparam name="TKey">
        /// The type of the key.
        /// </typeparam>
        /// <typeparam name="TValue">
        /// The type of the value.
        /// </typeparam>
        /// <param name="key">
        /// The key value.
        /// </param>
        /// <param name="value">
        /// The value, can be null.
        /// </param>
        /// <param name="relativeTime">
        /// The relative date time when the key value pair should expire and be purged from the cache.
        /// </param>
        private void AddImpl<TKey, TValue>(TKey key, TValue value, TimeSpan relativeTime)
        {
            if (!this.loaded)
            {
                this.ReviveCache();
            }

            if (relativeTime.TotalMilliseconds > (UInt32.MaxValue / 2))
            {
                throw new ArgumentException("Unable to add to cache (IsolatedStorageCacheProvider), time value (milliseconds) for expiry is greater than the max value 2147483647 (UInt32.MaxValue / 2).", "relativeTime");
            }

            var valueExpiry = new ValueExpiry
                                  {
                                      Value = value, 
                                      Expiry = DateTime.Now.AddMilliseconds(relativeTime.TotalMilliseconds)
                                  };

            lock (this.sync)
            {
                Observable.Timer(relativeTime)
                    .Finally(() =>
                    {
                        GC.Collect();
                        GC.WaitForPendingFinalizers();
                    })
                    .Subscribe(x => this.Purge(key), 
                    exn => this.log.Write("IsolatedStorageCacheProvider: Purge Failed - '{0}'.", exn.Message), 
                    () => this.log.Write("IsolatedStorageCacheProvider: Purge Completed..."));

                this.dictionary[key] = valueExpiry;
                this.persistRequired = true;
            }

            this.log.Write("IsolatedStorageCacheProvider: Added Value to cache, time: {0}, hash code - {1}.", DateTime.Now, key.GetHashCode());
        }

        /// <summary>
        /// Purges a key value pair from the cache identified by the key value
        /// </summary>
        /// <param name="key">
        /// The key of the key value pair. 
        /// </param>
        private void Purge(object key)
        {
            lock (this.sync)
            {
                this.dictionary.Remove(key);
                this.persistRequired = true;
            }

            this.log.Write("IsolatedStorageCacheProvider: Purged value from cache, time: {0}, hash code - {1}.", DateTime.Now, key.GetHashCode());
        }

        /// <summary>
        /// Performs the actual persisting of the cache.
        /// </summary>
        private void PersistCache()
        {
            if (!this.persistRequired)
            {
                return;
            }

            this.log.Write("IsolatedStorageCacheProvider: Persisting cache to file...");

            lock (this.sync)
            {
                this.SerializeCacheToFile();
                this.persistRequired = false;
            }
        }

        /// <summary>
        /// Performs the actual reviving of the cache, we only want to do this once so we've synchronised access.
        /// </summary>
        private void ReviveCache()
        {
            if (this.loaded)
            {
                return;
            }

            lock (this.sync)
            {
                if (this.loaded)
                {
                    return;
                }

                this.log.Write("IsolatedStorageCacheProvider: Reviving cache...");

                if (!this.serialisationAssemblies.Contains(this.GetType().Assembly))
                {
                    this.serialisationAssemblies.Add(this.GetType().Assembly);
                }

                if (!this.serialisationAssemblies.Contains(typeof(BaseModel).Assembly))
                {
                    this.serialisationAssemblies.Add(typeof(BaseModel).Assembly);
                }

                this.serialisationAssemblies.ForEach(a =>
                                                    {
                                                        this.log.Write("IsolatedStorageCacheProvider: Registering assembly, name - '{0}'.", a.FullName);
                                                        SilverlightSerializer.RegisterSerializationAssembly(a);
                                                    });

                var lowerApplicationName = this.applicationName.ToLowerInvariant();
                this.isolatedStorage = IsolatedStorageFile.GetUserStoreForApplication();
                
                if (!this.isolatedStorage.DirectoryExists(lowerApplicationName))
                {
                    this.isolatedStorage.CreateDirectory(lowerApplicationName);
                }

                this.cachePath = Path.Combine(lowerApplicationName, CacheFilename);
                this.log.Write("IsolatedStorageCacheProvider: Cache path - '{0}'.", this.cachePath);

                var now = DateTime.Now;
                var removeList = new List<object>();

                this.DeserializeCacheFromFile();

                if (this.dictionary.Count != 0)
                {
                    var keyValuePairs = new KeyValuePair<object, ValueExpiry>[this.dictionary.Count];
                    this.dictionary.CopyTo(keyValuePairs, 0);

                    foreach (var keyValuePair in keyValuePairs)
                    {
                        if (keyValuePair.Value.Expiry < now)
                        {
                            removeList.Add(keyValuePair.Key);
                        }
                        else
                        {
                            this.log.Write("IsolatedStorageCacheProvider: Key - '{0}'.", keyValuePair.Key);

                            var localPair = keyValuePair;
                            Observable.Timer(keyValuePair.Value.Expiry - now)
                                .Finally(() =>
                                {
                                    GC.Collect();
                                    GC.WaitForPendingFinalizers();
                                })
                                .Subscribe(x => this.Purge(localPair.Key),
                                exn => this.log.Write("IsolatedStorageCacheProvider: Purge Failed - '{0}'.", exn.Message),
                                () => this.log.Write("IsolatedStorageCacheProvider: Purge Completed..."));
                        }
                    }

                    removeList.ForEach(i => this.dictionary.Remove(i));
                }

                this.loaded = true;

                this.persistObserver = Observable.Interval(TimeSpan.FromMilliseconds(WriteInterval))
                    .Subscribe(l => this.PersistCache());
            }
        }

        /// <summary>
        /// Deserialize cache from file.
        /// </summary>
        private void DeserializeCacheFromFile()
        {
            try
            {
                if (this.isolatedStorage.FileExists(this.cachePath))
                {
                    var data = this.ReadFile();
                    this.dictionary = SilverlightSerializer.Deserialize<IDictionary<object, ValueExpiry>>(data);
                }

                if (this.dictionary == null)
                {
                    this.dictionary = new Dictionary<object, ValueExpiry>();
                }
            }
            catch (Exception exn)
            {
                this.log.Write("IsolatedStorageCacheProvider: Failed to deserialize persisted cache, message - '{0}'.", exn.Message);
                this.dictionary = new Dictionary<object, ValueExpiry>();
            }
        }

        /// <summary>
        /// Serialize the cache to file.
        /// </summary>
        private void SerializeCacheToFile()
        {
            try
            {
                var data = SilverlightSerializer.Serialize(this.dictionary);
                this.WriteFile(data);
            }
            catch (Exception exn)
            {
                this.log.Write("IsolatedStorageCacheProvider: Failed to serialize persisted cache, message -'{0}'.", exn.Message);
                this.dictionary = new Dictionary<object, ValueExpiry>();
            }
        }

        /// <summary>
        /// Read the cache file from isolated storage.
        /// </summary>
        /// <returns>
        /// Returns the read file as a byte array.
        /// </returns>
        private byte[] ReadFile()
        {
            using (var br = new BinaryReader(this.isolatedStorage.OpenFile(this.cachePath, FileMode.Open, FileAccess.Read, FileShare.None)))
            {
                var data = new byte[br.BaseStream.Length];
                br.Read(data, 0, (int)br.BaseStream.Length);

                return data;
            }
        }

        /// <summary>
        /// Write the cache file to isloated storage.
        /// </summary>
        /// <param name="data">
        /// The data to be written to file.
        /// </param>
        private void WriteFile(byte[] data)
        {
            using (var bw = new BinaryWriter(this.isolatedStorage.CreateFile(this.cachePath)))
            {
                bw.Write(data);
                bw.Flush();
            }
        }
    }
}